package com.wdl.datarest.implementation;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import javax.annotation.PostConstruct;

import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.wdl.datarest.data.Alarm;
import com.wdl.datarest.data.AlarmRepository;
import com.wdl.datarest.data.AlarmType;
import com.wdl.datarest.data.ConfigData;
import com.wdl.datarest.data.Person;
import com.wdl.datarest.data.PersonRepository;
import com.wdl.datarest.data.Device;
import com.wdl.datarest.data.DeviceRepository;
import com.wdl.datarest.prometheus.HealthData;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import springfox.documentation.annotations.ApiIgnore;

/**
 * When server starts retrieve the configuration data from the Person table and save in this cache.
 * This cache is for the mattress device only because other devices need not configure.
 *
 * Scenarios need update this cache:
 * - When GUI adds a new device
 * - When GUI updates any config data for a person
 * - When GUI reassign a device to someone else
 */
@RestController
@Api(value="aggrigated", tags={"Aggrigated APIs"}, description="Data Rest Aggrigated APIs")
public class DevCnfgCache {
    private static Logger log = LoggerFactory.getLogger("DevCnfgCache");

    // Set key to deviceId (readable string). The maximum supported devices is 100000.
    private Map<String, CacheData> devCnfgDataMap_ = new ConcurrentHashMap<String, CacheData>();
    private ScheduledExecutorService scheduledService = Executors.newSingleThreadScheduledExecutor();

    @Autowired
    private PersonRepository personRepo;

    @Autowired
    private DeviceRepository deviceRepo;

    @Autowired
    private AlarmRepository alarmRepo;

    @Autowired
    private HealthData healthData;

    @PostConstruct
    public void initCache() {
        log.info("+++++++++++++++++++++ Initializing device configuration cache ++++++++++++++++++++++");
        Iterable<Device> devices = deviceRepo.findAll();

        int total = 0;
        long startTime = System.currentTimeMillis() / 1000;
        for (Device device : devices) {
            total++;
            long deviceId = device.getId();
            String deviceIdString = device.getDeviceId();

            // check if the device is bound with person
            long personId = device.getPersonId();
            if (personId <= 0) {
                log.info("initCache(): device " + deviceIdString + " does not assign to person yet, ignore.");
                continue;
            }

            Person person = personRepo.findByIndex(personId);
            if (person == null) {
                log.error("initCache(): failed to find person with personId " + personId + " for device " + deviceIdString + ", ignore.");
                continue;
            }

            String personName = person.getPersonName();

            // ignore person already signed out, or no device is bound
            if (person.getCheckoutTime() != 0 || person.getUnbind1Time() != 0) {
                log.error("initCache(): person " + personName + " is out or haven't bound to device " + deviceIdString + ", data mismatch, ignore.");
                continue;
            }

            if (!deviceIdString.equals(person.getDevice1Id()) &&
                !deviceIdString.equals(person.getDevice2Id()) &&
                !deviceIdString.equals(person.getDevice3Id())) {
                log.error("initCache(): device mismatch for person [{}], expected: [{}], got [{}, {}, {}], ignore.",
                          personName, deviceIdString, person.getDevice1Id(), person.getDevice2Id(), person.getDevice3Id());
                continue;
            }

            if (device.getId() != person.getDevice1() &&
                device.getId() != person.getDevice2() &&
                device.getId() != person.getDevice3()) {
                log.error("initCache(): device mismatch for person [{}], expected: [{}], got [{}, {}, {}], ignore.",
                          personName, deviceId, person.getDevice1(), person.getDevice2(), person.getDevice3());
                continue;
            }

            CacheData cacheData = new CacheData();
            cacheData.setDeviceIndex(deviceId);
            cacheData.setDeviceId(deviceIdString);
            cacheData.setPersonId(personId);
            cacheData.setDeviceUpdateTime(device.getUpdateTime());
            cacheData.setHbUpperTH(person.getHeartUp());
            cacheData.setHbLowerTH(person.getHeartLow());
            cacheData.setBrUpperTH(person.getBreatheUp());
            cacheData.setBrLowerTH(person.getBreatheLow());
            cacheData.setBodyMoveTH(person.getMove());
            cacheData.setEnableHbAlarm(person.isHeartAlarm());
            cacheData.setEnableBrAlarm(person.isBreatheAlarm());
            cacheData.setEnableUpAlarm(person.isUpAlarm());
            cacheData.setEnableEdgeAlarm(person.isSideAlarm());
            cacheData.setEnableAwayAlarm(person.isAwayAlarm());
            cacheData.setSyncedToDev(true);
            cacheData.setSentReminder(false);

            // check if need to schedule reminder
            if (person.getTurnOverReminder() > 0 && !person.isTurnOverReminderAlarmFlag()) {
                log.info("Schedule turn over reminder fo personName=" + person.getPersonName() + ", interval=" + person.getTurnOverReminder() + " minutes.");
                scheduleTurnOverReminder(person.getId(), person.getTurnOverReminder());
                cacheData.setSentReminder(true);
            }

            log.info("Processed device [{}] that belongs to [{}].", deviceIdString, personName);
            devCnfgDataMap_.put(deviceIdString, cacheData);
        }
        log.info("========= Cache initialization finished, took " + (System.currentTimeMillis()/1000 - startTime) + " seconds, processed " + total);
    }

    public CacheData getCnfgDataFromCacheByDeviceIdString(String strDevId) {
        return devCnfgDataMap_.get(strDevId);
    }

    public CacheData getCnfgDataFromCacheByPersonId(long personId) {
        for (CacheData cacheData : devCnfgDataMap_.values()) {
            if (cacheData.getPersonId() == personId) {
                return cacheData;
            }
        }

        return null;
    }

    public void updateCacheByDeviceIdString(String deviceIdString, CacheData cacheData) {
        devCnfgDataMap_.put(deviceIdString, cacheData);
    }

    public Collection<CacheData> getCfgCaches() {
        return devCnfgDataMap_.values();
    }

    public void cacheAudit() {
        Iterable<Device> devices = deviceRepo.findAll();
        for (Device device : devices) {
            auditByDevice(device, true);
        }
    }

    private void auditByDevice(Device device, boolean syncedToDev) {
        if (device == null) {
            return;
        }

        long deviceId = device.getId();
        String deviceIdString = device.getDeviceId();
        CacheData cacheData = devCnfgDataMap_.get(deviceIdString);

        // check if the device is bound with person
        long personId = device.getPersonId();
        if (personId <= 0 && cacheData != null) {
            log.info("initCache(): device " + deviceIdString + " does not assign to person yet, remove it from cache.");
            devCnfgDataMap_.remove(deviceIdString);
            return;
        }

        Person person = personRepo.findByIndex(personId);
        if (person == null && cacheData != null) {
            log.error("initCache(): person has been removed from DB with personId " + personId + ", remove device " + deviceIdString + " from cache.");
            devCnfgDataMap_.remove(deviceIdString);
            return;
        }

        String personName = person.getPersonName();

        // ignore person already signed out, or no device is bound
        if ((person.getCheckoutTime() != 0 || person.getUnbind1Time() != 0 || person.getUnbind2Time() != 0) && cacheData != null) {
            log.error("initCache(): person " + personName + " is out or haven't bound to device " + deviceIdString + ", data mismatch. Remove it from cache.");
            devCnfgDataMap_.remove(deviceIdString);
            return;
        }

        if (cacheData != null &&
            !deviceIdString.equals(person.getDevice1Id()) &&
            !deviceIdString.equals(person.getDevice2Id()) &&
            !deviceIdString.equals(person.getDevice3Id())) {
            log.warn("initCache(): device mismatch for person [{}], expected: [{}], got [{}, {}, {}]. Remove it from cache.",
                     personName, deviceIdString, person.getDevice1Id(), person.getDevice2Id(), person.getDevice3Id());
            devCnfgDataMap_.remove(deviceIdString);
            return;
        }

        if (cacheData != null &&
            deviceId != person.getDevice1() &&
            deviceId != person.getDevice2() &&
            deviceId != person.getDevice3()) {
            log.warn("initCache(): device ID mismatch for person [{}], expected: [{}], got [{}, {}, {}]. Remove it from cache.",
                     personName, deviceId, person.getDevice1(), person.getDevice2(), person.getDevice3());
            devCnfgDataMap_.remove(deviceIdString);
            return;
        }

        // update cacheData, create if does not exist
        if (cacheData == null) {
            cacheData = new CacheData();
            // Force to send configure data to device
            syncedToDev = false;
            cacheData.setSentReminder(false);
        }

        cacheData.setDeviceIndex(deviceId);
        cacheData.setDeviceId(deviceIdString);
        cacheData.setPersonId(personId);
        cacheData.setDeviceUpdateTime(device.getUpdateTime());
        cacheData.setHbUpperTH(person.getHeartUp());
        cacheData.setHbLowerTH(person.getHeartLow());
        cacheData.setBrUpperTH(person.getBreatheUp());
        cacheData.setBrLowerTH(person.getBreatheLow());
        cacheData.setBodyMoveTH(person.getMove());
        cacheData.setEnableHbAlarm(person.isHeartAlarm());
        cacheData.setEnableBrAlarm(person.isBreatheAlarm());
        cacheData.setEnableUpAlarm(person.isUpAlarm());
        cacheData.setEnableEdgeAlarm(person.isSideAlarm());
        cacheData.setEnableAwayAlarm(person.isAwayAlarm());
        cacheData.setSyncedToDev(syncedToDev);

        if (log.isDebugEnabled()) {
            log.debug("initCache(): device " + deviceIdString + " was added to cache with person " + personName + ".");
        }
        devCnfgDataMap_.put(deviceIdString, cacheData);
    }

    private void scheduleTurnOverReminderWithCheck(Person person, CacheData cacheData) {
        // Removed by 3rd party, check why later on.
        if (cacheData == null) {
            cacheData = getCnfgDataFromCacheByPersonId(person.getId());
        }

        if (cacheData == null) {
            // cacheData is still null
            log.error("CacheData is null for personName=" + person.getPersonName() + ", personId=" + person.getId());
            return;
        }

        if (person.getTurnOverReminder() <= 0 || person.getDevice1() == 0) {
            cacheData.setSentReminder(false);
        }

        if (person.getTurnOverReminder() > 0 && person.getCheckoutTime() <= 0 && person.getDevice1() > 0 && !cacheData.isSentReminder()) {
            log.info("Schedule turn over reminder fo personName=" + person.getPersonName() + ", interval=" + person.getTurnOverReminder() + " minutes.");
            scheduleTurnOverReminder(person.getId(), person.getTurnOverReminder());
            cacheData.setSentReminder(true);
        }
    }

    @PostMapping(value = "/data/suImages")
    @ApiIgnore
    public void updateSuImages() {
        // reload SU images
        log.info("Reload SU images are required.");
        DevMonTask.updateDeviceImageInfo();
    }

    @PostMapping(value = "/data/configData")
    @ApiOperation(value = "Post actions after user updates the configuration data for a person. No returned contents.")
    public void updatePersonData(
        @ApiParam("ConfigData includes the personId to be updated, and the update type in string, " +
                  "such as \"checkout\", \"delete\", \"checkin\", \"updateDevice\", \"updateThreshold\", \"updateBasic\", \"updateContact\" and \"updateHospital\".")
        @RequestBody ConfigData configData) {
        long personId = configData.getPersonId();
        String deviceId = configData.getDevId();
        String deviceType = configData.getType();
        log.info("Got config data change, deviceId=[{}], personId=[{}], type=[{}].", deviceId, personId, deviceType);

        if (StringUtils.isEmpty(deviceId)) {
            log.error("deviceId [{}] cannot be empty!", deviceId);
            return;
        }
        CacheData cacheData = getCnfgDataFromCacheByDeviceIdString(configData.getDevId());

        switch (configData.getType()) {
        case "checkout":
        case "delete":
            // remove metric for prometheus for this person
            this.healthData.removeProMetrics4Person(personId);

            if (cacheData != null) {
                devCnfgDataMap_.remove(cacheData.getDeviceId());
            }

            break;
        case "checkin":
        case "updateDevice":
        case "updateThreshold":
            if (cacheData != null) {
                // Audit old device, unbinding
                long id = cacheData.getDeviceIndex();
                if (id > 0) {
                    // set syncedToDevice to false in order to transfer the updated data to device
                    this.auditByDevice(deviceRepo.findByIndex(id), false);
                }
            } else {
                // binding, check if we need to add to cache, audit new device
                Person person = personRepo.findByIndex(personId);
                if (person != null) {
                    this.auditByDevice(deviceRepo.findByDeviceId(configData.getDevId()).get(0), false);
                }
                scheduleTurnOverReminderWithCheck(person, cacheData);
            }
            break;
        case "updateBasic":
        case "updateContact":
        case "updateHospital":
            // These info need not put in cache, ignore.
            break;
        case "unbind":
            long id = cacheData.getDeviceIndex();
            if (id > 0) {
                Device dev = deviceRepo.findByIndex(id);
                if (dev != null) {
                    dev.setPersonId(0);
                    deviceRepo.save(dev);

                    // personId=0,设备会从内存移除
                    this.auditByDevice(dev, false);
                }
            }
            break;
        case "bind":
            Device dev = deviceRepo.findByDeviceId(configData.getDevId()).get(0);

            if (dev != null) {
                dev.setPersonId(personId);
                deviceRepo.save(dev);

                // 校验后添加到内存
                this.auditByDevice(dev, false);

                Person person = personRepo.findByIndex(personId);
                scheduleTurnOverReminderWithCheck(person, cacheData);
            }
            break;
        default:
            break;
        }
    }

    @PostMapping(value = "/data/ackAlarm")
    @ApiOperation(value = "Acknowledge an alarm with its alarm ID. No returned contents.")
    public void ackAlarmFromUi(@RequestParam("alarmId") long alarmId) {
        log.info("Ack alarm request, alarmId=" + alarmId);
        Alarm alarm = alarmRepo.findByIndex(alarmId);
        if (alarm == null) {
            return;
        }

        alarm.setAckedTime(System.currentTimeMillis());
        this.alarmRepo.save(alarm);
        LogUtils.logAlarm(alarm);

        // Update alarm related fields in person
        Person person = this.personRepo.findByIndex(alarm.getPersonId());
        if (person == null) {
            log.error("Failed to find person with personId=" + alarm.getPersonId() + ", alarmId=" + alarmId);
            return;
        }

        switch (alarm.getType()) {
        case HEART:
            person.setHideHeartAlarm(true);
            person.setHeartAlarmId(0);
            break;
        case BREATHE:
            person.setHideBreatheAlarm(true);
            person.setBreatheAlarmId(0);
            break;
        case UP:
            person.setHideUpAlarm(true);
            person.setUpAlarmId(0);
            break;
        case SIDE:
            person.setHideSideAlarm(true);
            person.setSideAlarmId(0);
            break;
        case AWAY:
            person.setHideAwayAlarm(true);
            person.setAwayAlarmId(0);
            break;
        case WET:
            person.setHideWetAlarm(true);
            person.setWetAlarmId(0);
            break;
        case MOVE:
            person.setHideMoveAlarm(true);
            person.setMoveAlarmFlag(false);
            person.setMoveAlarmAckTime(System.currentTimeMillis());
            person.setMoveAlarmId(0);
            break;
        case DEVICE:
            person.setHideDeviceAlarm(true);
            person.setDeviceAlarmId(0);
            break;
        case RING:
            person.setHideRingAlarm(true);
            person.setRingAlarmId(0);
            break;
        case LEFT:
            person.setHideLeftAlarm(true);
            person.setLeftAlarmId(0);
            break;
        case RIGHT:
            person.setHideRightAlarm(true);
            person.setRightAlarmId(0);
            break;
        default:
            log.error("Alarm type " + alarm.getType() + " is not supported yet!");
        }

        this.personRepo.save(person);
    }

    /**
     * Get the move counter for specified personId since the time specified with from in ms.
     */
    @GetMapping(value = "/data/movecount")
    public short getMoveCountAfter(
            @ApiParam("person id") @RequestParam("personId") long personId,
            @ApiParam("starting time in ms") @RequestParam("from") long from) {
        if (log.isDebugEnabled()) {
            log.debug("Got move counter query request, personId=" + personId + ", startTime=" + from);
        }
        CacheData cacheData = getCnfgDataFromCacheByPersonId(personId);
        if (cacheData == null) {
            return 0;
        }

        short count = 0;
        for (long move : cacheData.getMoves()) {
            if (move >= from) {
                count++;
            }
        }

        return count;
    }

    /**
     * Get the move details for specified personId since the time specified with from in ms.
     */
    @GetMapping(value = "/data/moves")
    @ApiOperation(value = "Get the movement details (list of movement time in ms in desc order) for a person since the time specified with \"from\" in ms.")
    public List<Long> getMovesAfter(
            @ApiParam("person id") @RequestParam("personId") long personId,
            @ApiParam("starting time in ms") @RequestParam("from") long from) {
        log.info("Got moves query request, personId=" + personId + ", startTime=" + from);
        List<Long> moves = new ArrayList<Long>();
        CacheData cacheData = getCnfgDataFromCacheByPersonId(personId);
        if (cacheData == null) {
            return moves;
        }

        for (long move : cacheData.getMoves()) {
            if (move >= from) {
                moves.add(move);
            }
        }

        Collections.reverse(moves);

        return moves;
    }

    /**
     * Get the move count for current hour.
     */
    @GetMapping(value = "/data/currentMoves")
    @ApiIgnore
    public Short getCurrentMoves(@RequestParam("personId") long personId) {
        log.debug("Got current moves query request, personId=" + personId);
        CacheData cacheData = getCnfgDataFromCacheByPersonId(personId);
        if (cacheData == null) {
            return 0;
        }

        return cacheData.getMoveTotal();
    }

    public void scheduleTurnOverReminder(long personId, short interval) {
        this.scheduledService.schedule(() -> {
            try {
                Person person = personRepo.findByIndex(personId);
                if (person == null ||
                    person.getCheckoutTime() > 0 ||
                    person.getTurnOverReminder() == 0 ||
                    person.getDevice1() <= 0 ||
                    person.isTurnOverReminderAlarmFlag()) {
                    // person is already left, or not using turn over reminder, or reminder is on-going, just skip.
                    return;
                }

                person.setTurnOverReminderAlarmFlag(true);
                Alarm alarm = new Alarm();
                alarm.setRaisedTime(new Date().getTime());
                alarm.setPersonId(personId);
                alarm.setDeviceId(person.getDevice1Id());
                alarm.setValue((short) 0);
                alarm.setInstitutionId(person.getInstitution() == null ? 1 : person.getInstitution().getId());
                alarm.setPersonName(person.getPersonName());
                alarm.setType(AlarmType.TURN_OVER);
                Alarm savedAlarm = alarmRepo.save(alarm);
                person.setTurnOverReminderAlarmId(savedAlarm.getId());
                personRepo.save(person);
            } catch (Exception e) {
                log.error("Failed to execute turnOverReminder task", e);
            }
        } , interval, TimeUnit.MINUTES);
    }
}